Papyrus.
go-mcp / mcp-server/Streamable HTTP.md

Streamable HTTP

最后更新 2026-06-24

我们在 Transport.md 中学习了两种MCP 支持的Transport,也知道了如何启动服务。Streamable HTTP将会作为未来常用的Transport,这里再举例说明,同时描述更多细节。

一个小例子

// MCP 服务端:hello_world 工具,根据 user_name 返回问候语。传输为 Streamable HTTP(见 docs/mcp-go-transport-examples.md §7)。
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)


func main() {
	const (
		host = "127.0.0.1"
		port = 8000
	)
	server := mcp.NewServer(&mcp.Implementation{
		Name:    "hello-world",
		Version: "0.1.0",
	}, nil)

	// 注册 hello_world 工具
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hello_world",
		Description: "根据 user_name 返回问候语",
	}, helloWorldTool)

	// http 的 handler
	handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
		return server
	}, nil)

	addr := fmt.Sprintf("%s:%d", host, port)
	log.Printf("streamable HTTP MCP: http://%s", addr)
	// 启动http服务,注册http handler
	err := http.ListenAndServe(addr, handler)
	if err != nil {
		log.Fatalf("server stopped: %v", err)
	}
}


type helloInput struct {
	UserName string `json:"user_name" jsonschema:"调用方用户名"`
}

func helloWorldTool(ctx context.Context, req *mcp.CallToolRequest, in helloInput) (*mcp.CallToolResult, any, error) {
	text := "Hello World, " + in.UserName
	return &mcp.CallToolResult{
		Content: []mcp.Content{
			&mcp.TextContent{Text: text},
		},
	}, nil, nil
}

现在我们就完全可以把mcp 服务当做一个 http 服务来启动了

go run cmd/mcp-hello-world/main.go

这次我们换一个更方便的连接工具 api fox,最新版本已支持MCP连接

img

img

代码解释

没变的代码

  server := mcp.NewServer(&mcp.Implementation{
		Name:    "hello-world",
		Version: "0.1.0",
	}, nil)

	// 注册 hello_world 工具
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hello_world",
		Description: "根据 user_name 返回问候语",
	}, helloWorldTool)

new 一个 server, 注册工具,这些代码都与原来一样。

将server注入到http handler

handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
	return server
}, nil)

现在MCP对外提供服务的是一个http 服务,需要把 MCP server 封装为一个 http 的 handler。然后正常指定ip端口启动 http 服务就可以了。也还挺简单的。

const (
	host = "127.0.0.1"
	port = 8000
)

addr := fmt.Sprintf("%s:%d", host, port)
log.Printf("streamable HTTP MCP: http://%s", addr)
// 启动http服务,注册http handler
err := http.ListenAndServe(addr, handler)
if err != nil {
	log.Fatalf("server stopped: %v", err)
}

细节扩展

StreamableHTTPOptions

将MCP server 注入到 http handler 时,调用的方法签名如下

func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *StreamableHTTPOptions) *StreamableHTTPHandler

第一个参数上边已经说过了,第二个参数可选配置,虽然说大部分不用太关心,但是也还是了解一下吧。

mcp-streamable-http-options

NewStreamableHTTPHandler(getServer, opts)opts != nil 时会将 *opts 拷贝进 handler,用于控制:

  • 有状态 / 无状态会话模型

  • 响应为 SSE 还是单次 JSON

  • 结构化日志

  • 断线后基于 Last-Event-ID 的流重放

  • 空闲会话自动关闭

  • Localhost 反 DNS 重绑定与跨源策略

// StreamableHTTPOptions configures the StreamableHTTPHandler.
type StreamableHTTPOptions struct {
	Stateless bool
	JSONResponse bool
	Logger *slog.Logger
	EventStore EventStore
	SessionTimeout time.Duration
	DisableLocalhostProtection bool
	CrossOriginProtection *http.CrossOriginProtection
}

Stateless

  • 默认 false:标准有状态模式。客户端携带 Mcp-Session-Id,服务端校验并复用同一逻辑会话(ServerSession / StreamableServerTransport)。

  • true:无状态模式:

  • 不校验 Mcp-Session-Id

  • 使用临时会话与默认初始化参数;

  • 服务端发往客户端的请求(server→client request)无法让客户回包,会被立即拒绝

  • 处理某次入站请求的上下文内发出的 server→client 通知仍可能随该次响应到达客户端(详见 StreamableServerTransport 文档)。

适用于仅需工具调用、无需完整双向 MCP 的简单部署。SDK 在 streamable_server.go 中说明:每次请求临时会话,请求结束后关闭。

JSONResponse

控制 POST 响应体格式(参见 MCP 规范「向服务端发送消息」相关章节):

取值 Content-Type 说明
false(默认) text/event-stream SSE,在流里先后发多条服务端消息,最终包含这次request 的 JSON-RPC response,即请求处理过程中可承载服务端主动消息
true application/json 单次 JSON,更简单;流式能力弱,部分场景下服务端消息走独立 SSE

Logger

  • nil:使用给定 slog.Logger 记录相关日志。

  • nil:不记录日志(并非默认 stdout)。

EventStore 断点续传

在 MCP Go SDK(StreamableHTTPOptions.EventStore)里,EventStore 是一个接口:用来给 Streamable HTTP 的 SSE 流做事件落盘/缓存,从而在断线后按 Last-Event-ID 续传、重放还没被客户端收到的消息。

没有 EventStore:连接断了,中间发过的事件一般没法按规范可靠地「从上次那条 SSE 事件接着播」。

有 EventStore(例如自带的 NewMemoryEventStore(nil))时,典型生命周期是:

  • 新开一条逻辑流 → Open
  • 每往流里写一段数据 → Append
  • 客户端带着 Last-Event-ID 来 GET 恢复 → After 用来按序号迭代重放后面的数据
  • 会话结束 → SessionClosed,可做清理

小结:EventStore = 可插拔的「SSE 事件存储 + 重放」后端;内存实现适合单机/演示,生产可以换成 Redis 等满足同一接口的实现。

SessionTimeout

  • 非零:会话在连续空闲超过该时长后自动关闭(实现会对进行中的 POST 做引用计数,避免误关)。

  • 零值:不因空闲自动关闭(仍可通过 DELETE 等方式结束会话)。

用于控制资源与内存占用。

DisableLocalhostProtection

默认启用 本地反 DNS 重绑定:从 127.0.0.1 / [::1] 接入时,若 Host 非本机形态,返回 403,降低恶意网页将解析指向本机后访问本地 MCP 的风险。

DNS 重绑定在干什么?

浏览器有 同源策略:网页上的 JS 一般只能向「与它来源一致」的主机发请求(协议 + 主机名 + 端口)。

攻击者控制的域名叫例如 evil.com。典型流程:

  1. 用户打开 http://evil.com(HTTPS 同理)。
  2. 首次解析evil.com公网 IP(攻击者服务器)。页面从公网加载,源(origin)为 **http://evil.com**
  3. 页面内 JS 再请求 http://evil.com:8080/...,浏览器仍视为 同源,允许发送。
  4. 攻击者对 DNS 做手脚,使 evil.com在短时间内改解析结果,例如改为 **127.0.0.1** 或内网 192.168.x.x。常见手法包括:
  • DNS 记录 TTL 极短
  • 同一域名在不同查询下返回不同地址(视解析器行为而定);
  • 多 A 记录等,让客户端「第二轮解析」拿到回环或内网地址。
  1. JS 请求的仍是 主机名 **evil.com**(同源未变),但 TCP 实际连到 本机或内网

结果:在浏览器眼中仍是同源请求,却能打到 本仅期望本机访问的 HTTP 服务(如仅监听 127.0.0.1、未鉴权的调试或 MCP HTTP 端口等)。

要点:不是伪造 TCP,而是用「仍是 evil.com」的语义,在 IP 层把流量转到本机或内网。

本地反 DNS 重绑定:依赖 Host 校验、强鉴权、减少裸露 HTTP localhost、优先 stdio/Unix socket 等,不要假设「监听 127.0.0.1 就只有自己会连」。

参考:MCP security best practices - local MCP server compromise

CrossOriginProtection

类型为 *http.CrossOriginProtection,用于自定义跨源行为。

  • nilNewStreamableHTTPHandler 会设为 &http.CrossOriginProtection{}(默认零值策略)。

什么是跨源?

  1. 用户已打开银行或管理后台,Cookie 里带着登录态。
  2. 用户又打开了攻击者页面 evil.com。
  3. 页面里自动提交表单或发 fetch:请求发到银行转账接口,浏览器会 自动带上该站的 Cookie。
  4. 银行若 没有额外校验(例如 CSRF Token、严格的 Origin/Referer、SameSite Cookie 等),就可能 按用户身份执行转账等操作。

要点:不是偷 Cookie,而是 借用浏览器「已有 Cookie」自动附带请求」这一行为,在用户当前浏览器会话仍有效时完成「伪造用户意图」的请求。